iT邦幫忙

2023 iThome 鐵人賽

DAY 19
0

大家好,今天我們要完成主頁的新聞瀏覽頁面,並加上無限捲軸的實作。畫面會如下圖:
https://ithelp.ithome.com.tw/upload/images/20231004/20135082DsVu7CTJn7.png
不過為什麼我們需要無限捲軸呢?試想一下,假設我們有 100 篇的新聞資料,這些新聞資料皆有各自的標題、圖片、文章等內容,如果在一次 api 中全數回傳,必然導致等待的時間提升,使用者體驗也會隨之下降。

這時候就需要將這 100 篇資料進行分頁處理,當看完第一頁的內容,真的有需要時再去跟 api 要第二頁的資料。減少了 api 的負擔,也控制住了等待時間。

無限捲軸可以讓這個獲取換頁內容的行為變得順暢,相當適合用在瀏覽資料上,因此我們今天會教大家如何將無限捲軸的概念實現於應用程式中。

不過要先請各位更新一下用於模擬後端資料的 db.json 檔案,我有進行更新。如果你有將 json server 部署至 vercel 上的,記得要將新的 db.json 上傳至 github,vercel 會自動幫你部署到最新版本;本地環境的話則記得要重啟 json server

網址:https://github.com/ChungHanLin/news_server_api/blob/main/db.json

調整程式碼架構

在開始實作今天的內容前,需要請各位調整一下我們專案程式碼架構。

請開啟 tab_layout.dart ,原先在建構頁面的 tabBuilder 中我們將頁面的外框骨架(CupertinoPageScaffold ) 與頂端列(CupertinoSliverNavigationBar ) 給四個 tab 共用,並靠著 getTabScreen() 函式來決定 tab 分頁需顯示的內容。

現在需要調整成將外框骨架及頂端列的部分整併至四個分頁中,詳細原因我們在今天的文章中會進行說明,不過簡單來說是因為要實現無限捲軸的功能才必須作出以下調整。舉 home_screen.dart 檔案為例,可參考下方程式碼:

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return const CupertinoPageScaffold(
      child: CustomScrollView(slivers: [
        CupertinoSliverNavigationBar(
          largeTitle: Text('主頁'),
          backgroundColor: CupertinoColors.white),
        SliverToBoxAdapter(child: Text('Home Screen'))
    ]));
  }
}

此時 tab_layout.dart 檔案的 tabBuilder 便可以簡化成如下:

 tabBuilder: (BuildContext context, int index) => getTabScreen(index)

取得主頁新聞 API

API 接口 - http://localhost:3000/posts?q=[搜尋字串]&_page=[頁碼]&_limit=[每頁資料筆數]

由於皆為測試資料,因此你會發現 api 接口與搜尋 api 相當,差別僅在於是否給定搜尋字串。因此可以針對 NewsPostRepository 中的 getPosts 進行改寫

Future<List<NewsPost>> getPosts({String query = '', int page = 1, int limit = 20}) async {
  try {
    final response = await http.get(Uri.parse('http://localhost:3000/posts?q=$query&_page=$page&_limit=$limit'));
    if (response.statusCode == 200) {
      final List<dynamic> posts = jsonDecode(response.body);
      return posts.map((post) => NewsPost.fromJson(post)).toList();
    }
    throw Exception('取得失敗');
  } catch (e) {
    return Future.error('連線錯誤');
  }
}

無限捲軸流程

我們先來了解無限捲軸的流程是如何實現的:

  1. 透過 api 獲取第一頁資訊
    1. 尚未回傳時整頁顯示讀取畫面
    2. 回傳成功顯示第一頁的新聞
  2. 滑動至底部時,再透過 api 獲取第二頁資訊
    1. 尚未回傳時維持顯示第一頁的新聞,但在底部顯示讀取畫面
    2. 回傳成功則將第二頁的新聞串接在第一頁的新聞下方
  3. 重複步驟 2 流程直至沒有更多新聞可以顯示,至最下方顯示「沒有更多新聞」的提示

上述步驟是無限捲軸的流程,因此我們需要定義幾個變數。請開啟 home_screen.dart ,並修改成 stateful widget。

class _HomeScreenState extends State<HomeScreen> {
  int page = 1;   // 記錄當前頁碼
  int limit = 10; // 一頁中有多少筆資料
  bool isBottom = false;  // 記錄是否已經沒有更多新聞
  late List<NewsPost> _posts = [];  // 存放新聞資料
  final ScrollController _scrollController = ScrollController(); // 用於監聽滑動的狀態
  // 以下省略

這裡出現了一個新面孔,我們來進行介紹

ScrollController

ScrollController 在flutter 中適用於控制可滾動 widget 的重要工具,可監聽滾動事件、控制滾動位置等等,因此在這個章節中被我們用於監聽是否滑動至該頁的底部,若是的話則繼續獲取下頁的新聞資訊。

首先需註冊監聽器,於 initState 階段中進行註冊。

@override
void initState() {
  super.initState();
  _scrollController.addListener(() { ... 要監聽的內容 });
}

由於是用於可滾動組件的控制器,因此當我們去檢視如:ListViewGridView 或是 CustomScrollView 等組件的類別定義,都會看到其中有一參數為 controller 可用於填入控制器,並監聽其滾動的行為。

ListView(
  controller: _scrollController,
  children: [ ... ]
);

最後當不需要該控制器時,需註銷監聽的行為

@override
void dispose() {
  super.dispose();
  _scrollController.dispose();
}

在監聽的過程中,ScrollController 中的 position 屬性可以提供當下滾動的位置以及狀態等資訊,以下是一些常用的屬性:

  • position.pixels :表示當前滾動位置
  • position.extentInside :顯示於螢幕中的區塊長度
  • position.extentBefore :未出現於螢幕中,於螢幕之上的區塊長度
  • position.extentAfter :未出現於螢幕中,於螢幕之下的區塊長度
  • position.maxScrollExtent :最大可滾動的長度

當我們認識了 scrollController 之後,知道將會被用於監聽滾動的進度,藉以達成無限捲軸的效果。那麼這跟一開始改程式碼架構之間的關係呢?

由於我們在建構每個 tab 的畫面時,都是使用

CustomScrollView(
  children: [
    CupertinoSliverNavigationBar( ... ),
    // 其他 Sliver 組件
  ]
)

這樣的架構來建構畫面,為要達成捲動內容時,縮小/放大頂端導覽列的效果。CustomScrollView 本身是滾動組件的一員,因此可直接用於註冊 ScrollController 來監聽滾動進度。

你可能會說,那這跟我在導覽列下方註冊一個 ListView 再加上 ScrollController 也可以達成一樣效果。但別忘了 ListView 的滾動是之於其本身,並不會牽動頂端列的滾動效果,下面我們放上比較圖就能明白了:
https://i.imgur.com/pxntIRv.gif
因為我們要將 controller 寫在 CustomScrollView 中,也就因此整體的架構需要大挪移拉~

現在我們了解了如何運用 ScrollController 了,把他加進我們的程式碼吧:

@override
void initState() {
  super.initState();
  // 註冊滾動監聽器
  _scrollController.addListener(() {
    // 當兩個條件成立時,才執行 fetchMore 函式來獲取更多新聞
    // 1. 當目前滾動位置已經達到最大可滾動範圍
    // 2. 尚未滑至最底部,表示還有更多新聞時
    if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent && !isBottom) {
      fetchMore();
    }
  });
  // 一開始先取得第一頁的新聞資訊
  NewsPostRepository().getPosts(page: page, limit: limit).then((value) {
    setState(() {
      // 若回傳的新聞列表長度已經比單頁的新聞比數,表示已經沒有下一頁了
      value.length < limit ? isBottom = true : isBottom = false;
      _posts = value;
    });
  });
}

@override
void dispose() {
  super.dispose();
  // 別忘了要註銷監聽器
  _scrollController.dispose();
}

接下來是實現 fetchMore() 函式,繼續取得下一頁的資料,並且串接至原先新聞列表的後方。

Future<void> fetchMore() async {
  late List<NewsPost> morePosts;
  try {
    morePosts = await NewsPostRepository().getPosts(page: page + 1, limit: limit);
  } catch (e) {
    // 避免呼叫 api 時發生錯誤導致整個列表崩潰,因此當發生錯誤時回傳空列表
    morePosts = [];
  }
  setState(() {
    page++;  // 遞增當前頁碼
    _posts.addAll(morePosts); // 將獲取的更多新聞串接至元新聞列表後方
    morePosts.length < limit ? isBottom = true : isBottom = false; // 同樣判斷回傳新聞長度
  });
}

讓我們來談談 CustomScrollView

現在我們進入到 build 函式中,我們使用 CustomScrollView 使的整個畫面可以進行滾動,而這些滾動效果都是透過 sliver 類型的組產生的。至於 sliver 是什麼,我們前面有提過,可以參考 Day15 的文章。

因此當我們要放置一個組件至 CustomScrollViewslivers 參數中時,必須要注意該組件要是 sliver 類型的。以下整理滾動式組件對應的 sliver 滾動組件

功能 滾動組件 Sliver 滾動組件
列表 ListView SliverList
網格 GridView SliverGrid
填滿螢幕,可容納多組件 PageView SliverFillViewPort

另外還有常用的

組件名稱 功能
SliverAppBar 顯示可伸縮的 AppBar
SliverToBoxAdapter 將組件轉換成 sliver 形式
SliverFillRemaining 用於填充剩餘滾動空間

因此當我們要列表顯示新聞卡片時,原先是使用 ListView.builder 來建構,到這裡就可以直接使用 SliverList.builder 拉~

CustomScrollView(
  controller: _scrollController, 
  slivers: [
    CupertinoSliverNavigationBar(...省略),
    _posts.isNotEmpty ? 
      // _posts 已有資料,開始建構新聞卡片
      SliverList.builder() : 
      // _posts 尚無資料,置中顯示讀取動畫。但因其不為 sliver 類型的,因此使用 SliverToBoxAdpater 包住
      const SliverToBoxAdapter(
        child: Center(child: CupertinoActivityIndicator())
      )
  ],

剩下最後一步拉,我們要實現新聞卡片的顯示邏輯

SliverList.builder(
  // 這個 + 1 相當重要,除了原先要顯示的新聞內容外,多出來的 1 個 item 將用於顯示有用的資訊
  itemCount: _posts.length + 1,
  itemBuilder: (conext, index) {
    // 一般 item 皆為顯示新聞卡片
    if (index < _posts.length) {
      return NewsPostCard(post: _posts[index]);
    } else if (isBottom) {
      // 若已經到底了,則多出來的 item 用於顯示提示文字
      return const SizedBox(
          height: 36,
          child: Center(
              child: Text(
            '已經到底拉',
            style: TextStyle(color: CupertinoColors.systemGrey),
          )));
    } else {
      // 表示尚未到底,則顯示讀取動畫
      return const SizedBox(
          height: 36,
          child: Center(child: CupertinoActivityIndicator()));
    }
})

這樣兜起來,我們就成功的實現無限捲軸的邏輯!不過都是同樣的新聞顯示效果看了有點了無新意XDD 所以各位可以參考我們的設計稿來實作看看不一樣的新聞卡片。這邊也就交給各位讀者自行實作拉。以下是我的最終結果:
https://ithelp.ithome.com.tw/upload/images/20231004/20135082w1kZtQFlHB.png

今日總結

簡單總結今天的內容:

  1. ScrollController 是一個用於控制滾動或監聽滾動進度的控制器,專用於滾動式儲組件上
  2. CustomScrollView 用於實現自定義的滾動效果,在其中的組件需為 sliver 類別組件

無限捲軸早就不是一個新概念,有一個很棒的套件名為 infinite_scroll_pagination 廣受開發者的喜愛,只要套用就可以很快的實現無限捲軸的效果。如果有興趣的可以到這裡看看他們提供的文件,寫的蠻完整的。不過透過今天的介紹,靠著簡單的邏輯思考同樣也實現出不錯的效果,相信一定比起套套件更有成就感!

今天的參考程式碼:https://github.com/ChungHanLin/micro_news_tutorial/tree/day19/micro_news_app


上一篇
[Day 18] 實戰新聞 APP - Custom Widget
下一篇
[Day20] 實戰新聞 APP - 使用彈出式視窗來顯示新聞吧 (CupertinoPopupSurface)
系列文
Flutter 從零到實戰 - 30 天の學習筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言